iT邦幫忙

2024 iThome 鐵人賽

DAY 11
0
AI/ ML & Data

從零開始學AI:數學基礎與程式碼撰寫全攻略系列 第 11

【Day 10】用卷積神經網路解CIFAR10影像辨識 - 如何建立一個通識化神經網路

  • 分享至 

  • xImage
  •  

前言

在今天的內容中,我們主要討論以下兩個重點。第一個重點是如何建立一個通用的神經網路模型。因為在神經網路中,模型可能會因為色彩通道或長寬不相等的情況,需要不斷重新計算每一層的輸出,這種方式顯得非常不切實際。第二個重點是通過對資料集(Dataset)進行二次包裝,來解釋資料集與數據加載器(Dataloader)究竟做了哪些事情。

建立神經網路並訓練

今天我們會用到昨日使用昨日建立的Trainer類別進行訓練,因此我們需要先將其文件命名為Trainer.py才能夠完成我們後續的步驟。

【STEP 1】正規化與資料下載

由於同樣是對圖像進行處理,在第一步我們同樣使用torchvision進行圖像前處理與正規化的動作。此外,我們需要多import昨日建立的Trainer類別來幫助我們進行訓練。

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
import matplotlib.pyplot as plt
from Trainer import Trainer

# 資料轉換操作,將圖片數據正規化並進行標準化處理
transform = transforms.Compose([
    transforms.ToTensor(),  # 將圖片轉為張量
    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))  # 標準化
])


# CIFAR-10 類別名稱
classes = ['plane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck']

# 載入 CIFAR-10 資料集 (訓練集與測試集)
trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)

在這裡,我們的操作與MNIST基本上沒有變化,唯一的差別是我們還需要定義CIFAR-10類別名稱。這是因為在深度學習中,我們所對應的標籤通常是數字而非文字,因為文字是無法直接計算損失值的。因此我們需要一個映射列表來對這些標籤進行轉換。

【STEP 2】建立資料加載器

在 PyTorch 中,通常會將一個容器型態的資料交由資料加載器進行包裝。這麼做的主要原因是將資料切割成批量,便於模型進行訓練。此外我們還能指定在每個周期訓練完後重新打亂數據的排列,這樣模型就不會在每個周期中學到相同排列的訓練資料。只需要設定 shuffle=True 這個參數即可。在非 Windows 環境中,PyTorch 的資料加載器可以透過設定 num_workers 來進行平行處理資料,但在 Windows 環境中,該值不能超過 0,否則程式會出現錯誤。

# 將資料加載器的輸出調整為字典格式以符合 Trainer 的需求
class DatasetWrapper(torch.utils.data.Dataset):
    def __init__(self, dataset):
        self.dataset = dataset

    def __len__(self):
        return len(self.dataset)

    def __getitem__(self, idx):
        data, label = self.dataset[idx]
        return {'input': data, 'labels': label}  # 對應Trainer的格式使用字典存放

資料加載器通常包括三個主要部分:__init____len____getitem__。其中,__init__ 負責初始化資料集,這點大家應該已經很熟悉了。__len__ 則用於返回資料集的大小,讓我們可以使用 len() 函數來計算其長度。至於 __getitem__,這是資料加載器的核心部分。當我們使用 data_loader[index] 語法來存取資料時,__getitem__ 會被調用,返回對應索引的資料。這種按需存取的方式特別適合處理大型資料集,避免一次性載入所有資料,從而節省記憶體。

# 重新包裝資料加載器
trainloader_wrapped = torch.utils.data.DataLoader(DatasetWrapper(trainset), batch_size=32, shuffle=True)
testloader_wrapped = torch.utils.data.DataLoader(DatasetWrapper(testset), batch_size=32, shuffle=False)
print(next(iter(trainloader_wrapped))['input'].shape)
# -----輸出-----
(32, 3, 32, 32)

接下來我們只需要將剛剛下載的資料傳入該類別中,就能讓資料在每次迭代時以批量方式運行。這裡我們還展示了每一筆迭代出來的圖片大小,其輸入維度是 (batch_size, input_channel, height, width)

【STEP 3】顯示圖片資料

同樣的,我們可以先繪製圖片來觀察這些圖片的特徵。在這裡我們直接使用dataloader來顯示圖片,但需要注意的是,由於我們在第一步驟中將圖像從(height, width, input_channel)轉換成(input_channel, height, width),並且進行了正規化(公式為(image - mean) / std),因此其還原公式為img_grid * std + mean,並且在顯示時需要將其維度轉換回來。

# 顯示圖片的工具函數
def imshow(dataloader, num_images=8):
    dataiter = iter(dataloader)
    images, labels = next(dataiter).values()  # 取得一個批次的圖片和標籤
    
    # 隨機選擇 num_images 張圖片
    selected_images = images[:num_images]
    selected_labels = labels[:num_images]

    # 把多張圖片組合成一個網格
    img_grid = torchvision.utils.make_grid(selected_images, nrow=num_images)
    
    # 反正規化
    # 由於我們設置的mean和std都是0.5,因此具體公式為`img_grid / 2 + 0.5`。
    img_grid = img_grid / 2 + 0.5
    
    # 轉換維度以適應 matplotlib 的顯示要求 (C, H, W -> H, W, C)
    npimg = img_grid.permute(1, 2, 0).numpy()

    # 顯示圖片
    plt.imshow(npimg)
    plt.axis('off')  # 隱藏座標軸

    # 設置標籤
    num_per_row = min(num_images, 8)  # 每行最多顯示8張圖片
    for i in range(num_images):
        plt.text(i * (npimg.shape[1] // num_per_row) + 5, npimg.shape[0] - 5, f'{selected_labels[i].item()}', 
                 color='white', fontsize=12, ha='center', backgroundcolor='black')

    plt.show()
imshow(trainloader_wrapped)

https://ithelp.ithome.com.tw/upload/images/20240925/20152236eCsHWYuS5p.png
而當程式執行過後我們就可以看到其對應的圖片與類別

【STEP 4】定義卷積神經網路模型

前天我們說到,在計算卷積神經網路模型時,需要計算每一層的卷積神經網路輸出維度。這是因為 input_shape_Hinput_shape_W 在遇到不同資料集時,輸出維度會有所不同。這時進入全連接層就可能會導致模型發生 shape error 的問題。

因此我們可以將前天提到的卷積神經網路輸出公式代入,以更好地計算每一層的輸出。如此一來,當我們更換資料集或放大縮小圖像時,模型就能夠自動適應其資料,以下將所有模型的輸出與輸入細節寫入註解中。

# 定義 CNN 模型
class CNNModel(nn.Module):
    def __init__(self, input_channels=3, input_shape_H=32, input_shape_W=32, output_shape = 10):
        super(CNNModel, self).__init__()
        # 第一層卷積:將輸入圖像 (batch_size, 3, 32, 32) 經過 32 個 3x3 的卷積核 (padding=1)
        # 輸出形狀為 (batch_size, 32, 32, 32)
        self.conv1 = nn.Conv2d(input_channels, 32, 3, padding=1)

        # 第二層卷積:將 (batch_size, 32, 32, 32) 經過 64 個 3x3 的卷積核 (padding=1)
        # 輸出形狀為 (batch_size, 64, 16, 16)
        self.conv2 = nn.Conv2d(32, 64, 3, padding=1)

        # 最大池化層:每次將高和寬減半
        # 在第一次池化後,輸出形狀為 (batch_size, 32, 16, 16)
        # 在第二次池化後,輸出形狀為 (batch_size, 64, 8, 8)
        self.pool = nn.MaxPool2d(2, 2)

        # 計算全連接層輸入的特徵圖大小
        # conv_output_H = input_shape_H // 4 = 32 // 4 = 8
        # conv_output_W = input_shape_W // 4 = 32 // 4 = 8
        conv_output_H = input_shape_H // 4
        conv_output_W = input_shape_W // 4

        # 全連接層1:輸入來自卷積層的展平結果,4096 = 64 * 8 * 8
        # 輸出 256 維度,輸入形狀 (batch_size, 4096),輸出形狀 (batch_size, 256)
        self.fc1 = nn.Linear(64 * conv_output_H * conv_output_W, 256)

        # 全連接層2:輸入 256 維度,輸出 64 維度,輸出形狀 (batch_size, 64)
        self.fc2 = nn.Linear(256, 64)

        # 全連接層3:輸入 64 維度,輸出 output_shape 維度,對應 output_shape 個類別
        # 輸出形狀為 (batch_size, output_shape)
        self.fc3 = nn.Linear(64, output_shape)

        self.criterion = nn.CrossEntropyLoss()

    def forward(self, input, labels):
        # 第一層卷積 + 池化:將輸入 (batch_size, 3, 32, 32) -> (batch_size, 32, 16, 16)
        x = self.pool(torch.relu(self.conv1(input)))

        # 第二層卷積 + 池化:將輸入 (batch_size, 32, 16, 16) -> (batch_size, 64, 8, 8)
        x = self.pool(torch.relu(self.conv2(x)))

        # 展平:將輸入 (batch_size, 64, 8, 8) 展平成 (batch_size, 4096)
        x = x.view(x.size(0), -1)

        # 全連接層1:輸入 (batch_size, 4096) -> (batch_size, 256)
        x = torch.relu(self.fc1(x))

        # 全連接層2:輸入 (batch_size, 256) -> (batch_size, 64)
        x = torch.relu(self.fc2(x))

        # 全連接層3:輸入 (batch_size, 64) -> (batch_size, 10)
        x = self.fc3(x)

        return self.criterion(x, labels), x # 回傳Loss與前向傳播結果。
        
model = CNNModel()

在這裡還有一個很重要的點,為了符合我們的Trainer設計,forward(self, input, labels)中的inputlabels必須要與資料載入器中定義的字典鍵(key)相同,並且在回傳時要把損失的位子設定成損失值,否則程式就會發生錯誤。

【STEP 5】開始訓練模型

由於訓練器已經建立完畢,因此只需將相關參數傳入即可。這裡因為我們沒有從訓練資料集中分割出驗證資料集,所以直接用測試資料集來代替。

# 定義損失函數和優化器
optimizer = optim.Adam(model.parameters(), lr=0.001)

# 建立 Trainer 實例並開始訓練
trainer = Trainer(
    epochs=10,
    train_loader=trainloader_wrapped,
    valid_loader=testloader_wrapped,
    model=model,
    optimizer=[optimizer],          # 當初設計的時候有考慮多的優化器,因此要用容器型態
)
# 開始訓練
trainer.train(show_loss=True)
# -----輸出-----
Train Epoch 2: 100%|██████████████████████████████████████████████████| 1563/1563 [00:14<00:00, 109.76it/s, loss=0.853]
Valid Epoch 2: 100%|████████████████████████████████████████████████████| 313/313 [00:01<00:00, 159.68it/s, loss=0.941]
Saving Model With Loss 0.83767
Train Loss: 0.74112| Valid Loss: 0.83767| Best Loss: 0.83767

https://ithelp.ithome.com.tw/upload/images/20240925/20152236b0qmQl3OlF.png
在這裡我們發現,模型在第三次訓練時達到了最佳損失,隨後的過程中出現了過擬合的現象。我們也看到儘管損失值達到了約0.83已經在可用範圍內,但明顯的有優化空間。

但是我不會先告訴你該如何優化的答案,而是希望你能先自行測試與調整這些參數設計與模型結構。這樣做的原因是,這些調整將成為實際應用中的寶貴經驗。不過我能給你一個提示:為什麼模型損失值下降如此之快,以及模型訓練損失值為何會持續下降。

總結

在今天的內容中,我們學習了如何使用昨天建立的Trainer類別來訓練卷積神經網路模型,並深入探討了資料加載器的運作原理與優勢。並且也知道了該如何建立了一個簡單的卷積神經網路模型,並自動計算每一層的輸出維度,已適應不同的資料輸入大小。

並且我們使用Trainer進行模型訓練,並繪製出損失值之間的相互關係,讓我們能一眼察覺到過度擬合的問題。不過,今天的內容還沒結束。你可以嘗試進一步優化模型,例如調整學習率、批次大小、網路層數,甚至是使用不同的優化器或正則化方法來提升模型的性能。這些實踐不僅能增強你的深度學習技能,也能幫助你更好地理解模型訓練中的細節。


上一篇
【Day 10】用卷積神經網路解CIFAR10影像辨識 - 建立一套屬於自己優化方式的訓練器
下一篇
【Day 12】在深度學習中電腦是如何辨識文字資料的
系列文
從零開始學AI:數學基礎與程式碼撰寫全攻略12
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言